聊聊 g0
很多时候,当我们跟着源码去理解某种事物时,基本上可以认为是以时间顺序展开,这是编年体的逻辑。还有另一种逻辑,纪传体,它以人物为中心编排史事,使得读者更聚焦于某个人物。以一种新的视角,把所有的事情串连起来,令人大呼过瘾。今天我们试着以这样一种逻辑再看 g0。
回顾一下 Go 夜读第 78 期,关于调度器源码分析的内容。我们讲过,与主线程绑定的 M 对应的 g0 的主要作用是提供一个比一般 goroutine 要大的多栈(64K)供 runtime 代码执行。
runtime·rt0_go
里会给主线程的 g0 分配栈空间:之后,主线程会与 m0 绑定,m0 又与 g0 绑定:
之后,又与 p0 绑定:
这样,主线程的这一套 GPM 就可以转起来了。接着,就创建了 main goroutine,放入 p0 的本地待运行队列。最后,通过 schedule()
函数进入调度循环。
前面说的是程序初始化的过程中,g0 是如何诞生的。当执行到 main.main()
函数,也说是用户在 main 包下写的 main 函数里,我们随手一句:
go func() {
// 要做的事
}()
就启动了一个 goroutine 时,在 Go 编译器的作用下,最终会转化成 newproc 函数。在 newproc 函数的内部,会在 g0 栈上调用 newproc1
函数,完成后续的工作。创建完成后,会将新创建的 goroutine 放入 _p_
的本地待运行队列。
因为新增加了一个 g,这时会尝试去唤醒一个 P 来一起执行任务。判断条件是:
if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted {
wakep()
}
即在有空闲 P 以及没有正在“找工作的 M”的情况下,才会尝试去唤醒一个 P。我们又知道,其实 P 的数量在程序运行过程中一般不会变化,所以这里所谓的唤醒其实就是把空闲的 P 利用起来。
通过 wakep() -> startm() -> newm() -> allocm() -> malg()
这条链路创建 g0,这里 g0 的栈大小实际上为 8KB
。
mp.g0 = malg(8192 * sys.StackGuardMultiplier) // sys.StackGuardMultiplier 在 linux 里为 1
g0
作为一个特殊的 goroutine,为 scheduler 执行调度循环提供了场地(栈)。对于一个线程来说,g0 总是它第一个创建的 goroutine。之后,它会不断地寻找其他普通的 goroutine 来执行,直到进程退出。
当需要执行一些任务,且不想扩栈时,就可以用到 g0 了,因为 g0 的栈比较大。g0 其他的一些“职责”有:创建 goroutine、deferproc 函数里新建 _defer、垃圾回收相关的工作(例如 stw、扫描 goroutine 的执行栈、一些标识清扫的工作、栈增长)等等。
因为 g0 这样一个特殊的 goroutine 所做的工作,使得 Go 程序运行地更快。
注:最近在 medium 上看到了一个非常赞的关于 Go 的博客博客[1],题图画得很有阅读的欲望。这篇文章也是参考于其中的一篇[2]。
References
[1]
博客: https://medium.com/a-journey-with-go[2]
其中的一篇: https://medium.com/a-journey-with-go/go-g0-special-goroutine-8c778c6704d8